// Copyright 2018 The LUCI Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package digests holds types used by selfupdate mechanism to pin client
// hashes.
package digests

import (
	"bufio"
	"fmt"
	"io"
	"sort"
	"strings"

	"google.golang.org/protobuf/proto"

	"go.chromium.org/luci/common/errors"
	"go.chromium.org/luci/common/iotools"

	api "go.chromium.org/luci/cipd/api/cipd/v1"
	"go.chromium.org/luci/cipd/common"
	"go.chromium.org/luci/cipd/common/cipderr"
)

// ClientDigestsFile holds a mapping "platform => hash of the client binary for
// given platform", for some particular version of the CIPD client (provided
// elsewhere).
//
// It is used to "lock" the client binary during updates, much in a same way
// $ResolvedVersions file is used to "lock" hashes of the packages. Unlike
// the latter, the client version file holds digests of the CIPD client binary
// itself, not a CIPD package.
//
// This file is parsed by 'cipd selfupdate' and also by various bootstrap
// scripts that fetch the initial copy of the client. For that reason the format
// is relatively simple:
//
// """
// # Comment.
//
// <platform> <hash algo> <hex digest>
// ...
// """
//
// Where <platform> is one of ${platform} values (e.g. "linux-amd64"), and
// <hash algo> is one of stringified case-insensitive HashAlgo enum values from
// api/cas.proto (e.g. "sha256").
//
// Comments are allowed and must occupy their own line. Empty new lines are
// skipped. All non-empty lines have 3 fields (with any number of whitespace
// characters between fields).
//
// Order of lines is not significant.
type ClientDigestsFile struct {
	entries []clientDigestEntry
}

type clientDigestEntry struct {
	plat string
	ref  *api.ObjectRef
}

// AddClientRef appends the client's digest given as ObjectRef.
//
// Returns an error (platform, hash algo) combination has already been added or
// the hash is unrecognized.
func (d *ClientDigestsFile) AddClientRef(plat string, ref *api.ObjectRef) error {
	if err := common.ValidateObjectRef(ref, common.KnownHash); err != nil {
		return err
	}
	for _, e := range d.entries {
		if e.plat == plat && e.ref.HashAlgo == ref.HashAlgo {
			return errors.Reason("%s hash for %s has already been added", ref.HashAlgo, plat).Tag(cipderr.BadArgument).Err()
		}
	}
	d.entries = append(d.entries, clientDigestEntry{plat, ref})
	return nil
}

// ClientRef returns an expected client ObjectRef for the given platform.
//
// Returns the best hash (higher algo number) or nil if there are no digests
// for this platform at all.
func (d *ClientDigestsFile) ClientRef(plat string) (ref *api.ObjectRef) {
	for _, e := range d.entries {
		if e.plat == plat && (ref == nil || e.ref.HashAlgo > ref.HashAlgo) {
			ref = e.ref
		}
	}
	return
}

// Contains returns true if the given ref is among refs for the given platform.
//
// Compares 'ref' to all hashes corresponding to 'plat', not only the best one.
func (d *ClientDigestsFile) Contains(plat string, ref *api.ObjectRef) bool {
	for _, e := range d.entries {
		if e.plat == plat && proto.Equal(ref, e.ref) {
			return true
		}
	}
	return false
}

// Sort orders the entries by (platform, -hashAlgo).
func (d *ClientDigestsFile) Sort() {
	sort.Slice(d.entries, func(i, j int) bool {
		l, r := d.entries[i], d.entries[j]
		if l.plat != r.plat {
			return l.plat < r.plat
		}
		return l.ref.HashAlgo > r.ref.HashAlgo // more recent algos first
	})
}

// Equal returns true if files have same entries in same order.
func (d *ClientDigestsFile) Equal(a *ClientDigestsFile) bool {
	if len(d.entries) != len(a.entries) {
		return false
	}
	for i, l := range d.entries {
		if r := a.entries[i]; l.plat != r.plat || !proto.Equal(l.ref, r.ref) {
			return false
		}
	}
	return true
}

// Serialize writes the ClientDigestsFile to an io.Writer.
//
// 'version' and 'versionFile' are used to construct a meaningful comment
// footer.
func (d *ClientDigestsFile) Serialize(w io.Writer, version, versionFile string) error {
	_, err := iotools.WriteTracker(w, func(w io.Writer) error {
		fmt.Fprintf(w, "# This file was generated by\n")
		fmt.Fprintf(w, "#\n")
		fmt.Fprintf(w, "#  cipd selfupdate-roll -version-file %s \\\n", versionFile)
		fmt.Fprintf(w, "#      -version %s\n", version)
		fmt.Fprintf(w, "#\n")
		fmt.Fprintf(w, "# Do not modify manually. All changes will be overwritten.\n")
		fmt.Fprintf(w, "# Use 'cipd selfupdate-roll ...' to modify.\n\n")

		// Align fields nicely.
		max := []int{0, 0}
		for _, e := range d.entries {
			if l := len(e.plat); l > max[0] {
				max[0] = l
			}
			if l := len(e.ref.HashAlgo.String()); l > max[1] {
				max[1] = l
			}
		}

		for _, e := range d.entries {
			algo := strings.ToLower(e.ref.HashAlgo.String())
			fmt.Fprintf(w, "%-*s%-*s%s\n",
				max[0]+2, e.plat,
				max[1]+2, algo,
				e.ref.HexDigest,
			)
		}

		return nil
	})
	if err != nil {
		return errors.Annotate(err, "failed to write client digests file").Tag(cipderr.IO).Err()
	}
	return nil
}

// ParseClientDigestsFile parses previously serialized client digests file.
//
// Unrecognized algorithms are silently skipped, to be compatible with files
// generated by the future versions of CIPD that may use different algorithms.
func ParseClientDigestsFile(r io.Reader) (*ClientDigestsFile, error) {
	res := &ClientDigestsFile{}

	lineNo := 0
	makeError := func(fmtStr string, args ...interface{}) error {
		args = append([]interface{}{lineNo}, args...)
		return errors.Reason("failed to parse client digests file (line %d): "+fmtStr, args...).Tag(cipderr.BadArgument).Err()
	}

	scanner := bufio.NewScanner(r)
	for scanner.Scan() {
		lineNo++

		line := strings.TrimSpace(scanner.Text())
		if line == "" || line[0] == '#' {
			continue
		}

		tokens := strings.Fields(line)
		if len(tokens) != 3 {
			return nil, makeError("each line must have format \"<platform> <algo> <digest>\"")
		}

		algoIdx := api.HashAlgo_value[strings.ToUpper(tokens[1])]
		if algoIdx == 0 {
			continue // skip unknown algorithms
		}
		ref := &api.ObjectRef{
			HashAlgo:  api.HashAlgo(algoIdx),
			HexDigest: tokens[2],
		}
		if err := common.ValidateObjectRef(ref, common.KnownHash); err != nil {
			return nil, makeError("%s", err)
		}

		if err := res.AddClientRef(tokens[0], ref); err != nil {
			return nil, makeError("%s", err)
		}
	}

	if err := scanner.Err(); err != nil {
		return nil, errors.Annotate(err, "failed to read client digests file").Tag(cipderr.IO).Err()
	}
	return res, nil
}
